ci(sdk): add proto drift detection and sync notifications - #3123
Conversation
Add automated proto drift detection for Go and TypeScript SDKs with issue-based notifications when SDK builds break due to proto changes. Signed-off-by: Ignas Baranauskas <ibaranau@redhat.com>
rhuss
left a comment
There was a problem hiding this comment.
A first round of human review. Looks good in general, some minor comments inline.
I hand over now to my review agents for additional findings.
| - name: go | ||
| drift_task: "go:proto:drift" | ||
| - name: typescript | ||
| drift_task: "sdk:ts:proto:drift" |
There was a problem hiding this comment.
Why are the tasks are named following a different scheme ? I would propose to use also sdk:go:proto:drift for the naming of the golang drift task.
| sections.append("</details>") | ||
| sections.append("") | ||
|
|
||
| return "\n".join(sections) |
There was a problem hiding this comment.
I've probably would use a more template like approach (i.e. a template with placeholder that are filled in with the data), then this very rudimentary and fragile list approach. That way it would be much nicer to see already how the report would look like eventually and is also easier to adapt. Not sure what Python offers here out of the pocket, but every language has this kind of templates (go as part of its std library). Your agent should be easily capable of converting this to a template based approach (templates typically also allow inside loops, too, e.g. for rendering the tables)
There was a problem hiding this comment.
+1 on the template approach. For a CI utility script, a full template engine like Jinja2 would be overkill though. A good middle ground: define the template as a multiline string with .format() and pre-render the conditional/loop sections into simple strings before substituting.
ISSUE_TEMPLATE = """\
## Proto Drift Report
**Summary**: {summary}
{file_table}
{build_section}
## Fix Commands
```bash
mise run {proto_task} # Regenerate bindings
mise run {build_task} # Verify build
mise run {test_task} # Run testsAgent Instructions
{agent_section}
"""
def generate_issue_body(drift_report, build_report, sdk):
paths = SDK_CONFIGS[sdk]
drifted = [f for f in drift_report.get("files", []) if f.get("status") != "synced"]
file_table = _render_file_table(drifted) if drifted else ""
build_section = _render_build_section(build_report) if build_report else ""
agent_section = _render_agent_section(sdk, drifted, build_report)
return ISSUE_TEMPLATE.format(
summary=drift_report.get("summary", "unknown"),
file_table=file_table,
build_section=build_section,
proto_task=paths["proto_task"],
build_task=paths["build_task"],
test_task=paths["test_task"],
agent_section=agent_section,
)
The template shape is immediately visible at the top of the file (you can see the final markdown structure at a glance), the conditional sections are isolated into small helper functions that each return a string, and there are zero new dependencies. The sub-sections (file table, agent prompt) are still built in Python, but they're small enough that it doesn't matter.
| """ | ||
| hide = true | ||
|
|
||
| ["go:proto:drift"] |
There was a problem hiding this comment.
as said above, I would align the naming convention along side the typescript task (and add a sdk: prefix)
| return subprocess.CompletedProcess([], returncode, stdout=stdout, stderr=stderr) | ||
|
|
||
|
|
||
| class TestGenerateIssueBody: |
There was a problem hiding this comment.
The tests only check for golang based drifts. Would it make sense to add some typescript-based tests ?
rhuss
left a comment
There was a problem hiding this comment.
cc-review Summary
What Went Well
- Clean separation of concerns: Drift detection in per-SDK mise tasks, issue management in standalone Python CLI, workflow orchestration in YAML. Each layer independently testable and replaceable.
- TypeScript drift detection correctly adapts to the gitignored-stubs model by using regen-then-typecheck rather than file diffing (
tasks/typescript.toml:70). - Agent-consumable prompt in issue bodies (
sdk_sync.py:100-167): well-structured prompt with context, steps, and scope constraints inside a collapsible<details>block, aligned with the project's agent-first identity. - Issue deduplication by label prevents daily cron drift issues from piling up (
sdk_sync.py:248-308). - Robust error handling in workflows:
|| trueon drift commands,jq -evalidation,::warning::/::error::annotations with stderr capture for debugging.
Findings
| Severity | File | Description | Source |
|---|---|---|---|
| Important | tasks/typescript.toml:78 |
TS drift summary misattributes proto-gen failure as typecheck | correctness |
| Important | sdk-sync-dashboard.yml:25 |
No timeout-minutes on CI jobs (6h default) | production |
| Important | tasks/go.toml:180 |
go:proto:drift duplicates ~70% of go:proto:check | architecture |
| Minor | sdk-proto-check.yml:78 |
Fixed heredoc delimiter enables output injection | security |
| Minor | tasks/go.toml:209 |
NDJSON_FILE variable actually contains TSV | architecture |
| Minor | tasks/go.toml:239 |
Exit code re-derived from file already consumed by jq | architecture |
| Minor | tasks/go.toml:209 |
NDJSON temp file not in EXIT trap | correctness |
| Minor | sdk_sync.py:220 |
_ensure_label swallows label creation failure | correctness |
| Minor | sdk_sync.py:190 |
subprocess.run calls have no timeout | production |
| Minor | sdk-sync-dashboard.yml:46 |
Dashboard extensibility requires YAML changes per SDK | goal-alignment |
Notable Observations
| File | Description | Source |
|---|---|---|
sdk_sync_test.py |
manage_issue error returns have zero coverage | test-quality |
sdk_sync_test.py |
CLI entrypoint and exit code logic untested | test-quality |
sdk_sync_test.py |
Mocked _run_cmd args never inspected | test-quality |
sdk_sync_test.py |
_find_open_issue JSON error handling untested | test-quality |
sdk_sync_test.py |
No-build-report agent prompt path unverified | test-quality |
| (PR description) | PR Changes section omits 3 files including PR workflow | goal-alignment |
Review Details
- Findings posted: 16 (3 Important, 7 Minor, 6 Notable)
- Findings reviewed and not posted: 0
- Gate outcome: FAIL (3 Important findings)
- Participating agents: correctness, architecture, security, production, test-quality, goal-alignment
| echo '{"sdk":"typescript","synced":false,"error":"jq not found"}' | ||
| exit 1 | ||
| fi | ||
|
|
There was a problem hiding this comment.
Important: The failure branch always reports "typecheck failed after proto regeneration" regardless of which step actually failed. The && short-circuits: if mise run sdk:ts:proto fails, mise run sdk:ts:typecheck never runs, but the summary blames typecheck.
Why this matters: Creates false-positive drift issues from transient CI failures (e.g., npm install timeout). The misleading summary sends investigators down the wrong path, wasting debugging time on proto drift that doesn't exist.
Suggested fix: Run the two steps separately to report which one actually failed:
if ! mise run sdk:ts:proto > "$LOG_FILE" 2>&1; then
jq -n -c '{sdk:"typescript", synced:false, files:[], summary:"proto generation failed"}'
exit 1
fi
if ! mise run sdk:ts:typecheck >> "$LOG_FILE" 2>&1; then
jq -n -c '{sdk:"typescript", synced:false, files:[], summary:"typecheck failed after proto regeneration"}'
exit 1
fiSource: correctness agent
| cancel-in-progress: true | ||
|
|
||
| jobs: | ||
| sdk_sync_check: |
There was a problem hiding this comment.
Important: No timeout-minutes set on any job. The sdk_sync_check job runs buf generate, full SDK builds, and test suites for both Go and TypeScript sequentially on linux-amd64-cpu8 runners. GitHub Actions defaults to 6 hours.
Why this matters: A hanging step (npm registry timeout, test deadlock, buf plugin stall) occupies an expensive 8-core runner for up to 6 hours. For the daily cron, this also blocks the entire issue_management pipeline since it depends on sdk_sync_check.
Suggested fix: Add timeout-minutes per job:
sdk_sync_check:
timeout-minutes: 30 # builds + tests for two SDKs
# In sdk-proto-check.yml:
sdk_proto_drift:
timeout-minutes: 15 # drift detection only
issue_management:
timeout-minutes: 5 # just gh CLI callsSource: production agent
| @@ -178,3 +178,74 @@ fi | |||
| echo "Proto check passed: generated files are up to date." | |||
| """ | |||
| hide = true | |||
There was a problem hiding this comment.
Important: This new go:proto:drift task shares ~70% of its logic with the existing go:proto:check task (lines 138-178): tool availability checks, tmpdir creation with trap, sed-based buf.gen.yaml template substitution, buf generate invocation, and the two-pass file comparison loop. Only the output format (JSON vs human-readable text) differs.
Why this matters: If the buf generation approach or template substitution pattern changes, both tasks must be updated independently. A change to one without the other will cause silent divergence in drift detection results.
Suggested fix: Extract the shared logic (tool checks, tmpdir, buf generate, file diffing) into a shared script in tasks/scripts/ that accepts an --output-format flag (json or text). Both tasks delegate to it. Alternatively, refactor go:proto:check to output JSON and add a thin wrapper for human-readable formatting.
Source: architecture agent
| } >> "$GITHUB_OUTPUT" | ||
| echo "synced=$SYNCED" >> "$GITHUB_OUTPUT" | ||
| else | ||
| echo "::warning::Proto drift check failed: unable to parse report" |
There was a problem hiding this comment.
Minor (defense-in-depth): The fixed heredoc delimiter REPORT_EOF for multiline GITHUB_OUTPUT values is a documented CI/CD antipattern. If $REPORT ever contained a line that is exactly REPORT_EOF, the heredoc would terminate early, allowing injection of arbitrary step outputs.
Why this matters: Current mitigations (compact JSON output, jq validation) make exploitation unlikely, but they protect against this by accident, not by design. If the output format changes (e.g., dropping -c, adding diagnostic lines), the protection disappears silently. This pattern appears 6 times across both workflow files.
Suggested fix: Use a randomized delimiter:
DELIM="REPORT_EOF_$(openssl rand -hex 8)"
{
echo "report<<$DELIM"
echo "$REPORT"
echo "$DELIM"
} >> "$GITHUB_OUTPUT"Source: security agent
| fi | ||
|
|
||
| NDJSON_FILE=$(mktemp) | ||
|
|
There was a problem hiding this comment.
Minor: NDJSON_FILE is named after "Newline-Delimited JSON" but stores tab-separated values (printf '%s\t%s\t%s\n'). The jq -R invocation later parses it as raw text split by tabs.
Why this matters: A maintainer adding a new field may assume each line is a JSON object and write jq filters that fail silently.
Suggested fix: Rename to DRIFT_TSV or RESULTS_FILE.
Source: architecture agent
| summary: (if length == 0 then "all files synced" | ||
| else "\\(length) file(s) drifted" end)} | ||
| ' "$NDJSON_FILE" | ||
|
|
There was a problem hiding this comment.
Minor: After jq outputs the JSON report (which already contains "synced": true/false), the script re-reads $NDJSON_FILE with wc -l to determine the exit code. This is a redundant read of information already computed by jq.
Why this matters: A reader must trace two independent code paths (jq filter and wc) to verify they agree on drift status. If someone refactors the jq filter, the wc -l count could diverge.
Suggested fix: Capture jq output, extract synced, and use it for exit code:
REPORT=$(jq ... "$NDJSON_FILE")
echo "$REPORT"
SYNCED=$(echo "$REPORT" | jq -r '.synced')
[ "$SYNCED" = "true" ] && exit 0 || exit 1Source: architecture agent
|
|
||
| NDJSON_FILE=$(mktemp) | ||
|
|
||
| for f in $(find "$WORK_DIR/proto" -name '*.go' -type f | sort); do |
There was a problem hiding this comment.
Minor: NDJSON_FILE=$(mktemp) creates a temp file, but the EXIT trap only cleans up WORK_DIR. If jq fails and set -e terminates the script, this temp file leaks.
Why this matters: Low impact (CI containers are ephemeral), but violates the cleanup pattern established by the existing trap.
Suggested fix: Update the trap after creating the file:
NDJSON_FILE=$(mktemp)
trap 'rm -rf "$WORK_DIR"; rm -f "$NDJSON_FILE"' EXITSource: correctness agent
| ) | ||
|
|
||
|
|
||
| def _ensure_label(repo: str, label: str, description: str) -> None: |
There was a problem hiding this comment.
Minor: _ensure_label calls _run_cmd(["gh", "label", "create", ...]) but discards the return value. If label creation fails (permissions, rate limit), manage_issue then tries gh issue create --label <non-existent-label>, which returns a 422. The error IS eventually propagated, but the root cause is obscured.
Why this matters: The operator sees "Failed to create issue" instead of "Failed to create label," making debugging harder on first run against a new repo.
Suggested fix: Check the return code and log:
result = _run_cmd(["gh", "label", "create", ...], capture=True)
if result.returncode != 0:
print(f"Warning: failed to create label '{label}': {result.stderr}", file=sys.stderr)Source: correctness agent
| sections.append("") | ||
|
|
||
| return "\n".join(sections) | ||
|
|
There was a problem hiding this comment.
Minor: _run_cmd wraps subprocess.run without a timeout parameter. All gh CLI calls (label view/create, issue list/edit/create) make HTTP requests to the GitHub API.
Why this matters: During a GitHub API outage, any gh call could hang indefinitely. Combined with no job-level timeout in the workflow, this could tie up a runner for 6 hours.
Suggested fix: Add a timeout parameter with a reasonable default:
def _run_cmd(cmd, cwd=None, capture=False, stdin_data=None, timeout=60):
return subprocess.run(cmd, cwd=cwd, capture_output=capture,
text=True, input=stdin_data, timeout=timeout)Source: production agent
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | ||
|
|
||
| - name: Install tools | ||
| run: mise install --locked |
There was a problem hiding this comment.
Minor: The sdk_sync_check job uses hardcoded per-SDK steps and outputs rather than a matrix strategy. Adding a third SDK requires duplicating step blocks (~25 lines of YAML), not just "a mise task + config entry" as the issue acceptance criteria state.
Why this matters: The PR-triggered workflow (sdk-proto-check.yml) delivers on extensibility via matrix, but the dashboard workflow does not. This is partly a GitHub Actions limitation (matrix outputs can't be dynamically named).
Suggested fix: Document the workflow modification steps needed to add a new SDK in a code comment, or restructure using artifact-based output passing.
Source: goal-alignment agent
Summary
Add daily CI workflow that detects proto drift in Go and TypeScript SDKs, runs build checks when drift is found, and auto-manages GitHub issues (create/update on breakage, close on resolution).
Related Issue
Closes #2825
Changes
.github/workflows/sdk-sync-dashboard.yml— New workflow: runs daily at 06:00 UTC, checks both SDKs in a single job, manages drift issues per-SDK via matrix strategytasks/go.toml— Two new tasks:go:proto:drift(generates fresh protos to a tmpdir, diffs against committed files, outputs JSON report) andgo:proto:build-check(delegates to shared build check script)tasks/scripts/sdk_build_check.sh— Generic step runner: executes a sequence of mise tasks, captures logs, outputs structured pass/fail JSONtasks/scripts/sdk_sync.py— Issue management CLI: generates issue bodies with drift tables, build logs, fix commands, and an agent-consumable prompt; deduplicates by labelTesting
mise run go:proto:drift— outputs valid JSON when in sync (exit 0) and detects drift when a proto file is modified (exit 1, correct JSON)mise run go:proto:build-check— reports success when gen/build/test passmise run sdk:ts:proto:drift— outputs valid JSON when in syncmise run sdk:ts:proto:build-check— reports success when gen/typecheck/test passsdk_sync.py generate_issue_body— produces correct issue body for both Go and TypeScriptmise run pre-commitpassesworkflow_dispatchChecklist
cc @rhuss